22. Warsztaty Reacta cz.2
Wyzwania:
- nauczysz się implementować Material-UI,
- zastosujesz Reduksa w projekcie,
- samodzielnie rozwiniesz aplikację.
22.1. Implementacja Material-UI
Zgodnie z obietnicą z poprzedniego modułu, zajmiemy się teraz wykorzystaniem Material UI do stworzenia jednego z widoków.
Tym razem jednak, w ramach warsztatów, samodzielnie zajmiesz się przeanalizowaniem kodu komponentu Waiter. Wykorzystaliśmy w nim obiekt z przykładową treścią. Symuluje on dane, które będziemy już niedługo otrzymywać z reduksowego stanu aplikacji. Dzięki temu będzie nam dużo łatwiej podmienić go, kiedy zaimplementujemy Reduksa.
import React from 'react';
import styles from './Waiter.module.scss';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Paper from '@material-ui/core/Paper';
import Button from '@material-ui/core/Button';
const demoContent = [
{id: '1', status: 'free', order: null},
{id: '2', status: 'thinking', order: null},
{id: '3', status: 'ordered', order: 123},
{id: '4', status: 'prepared', order: 234},
{id: '5', status: 'delivered', order: 345},
{id: '6', status: 'paid', order: 456},
];
const renderActions = status => {
switch (status) {
case 'free':
return (
<>
<Button>thinking</Button>
<Button>new order</Button>
</>
);
case 'thinking':
return (
<Button>new order</Button>
);
case 'ordered':
return (
<Button>prepared</Button>
);
case 'prepared':
return (
<Button>delivered</Button>
);
case 'delivered':
return (
<Button>paid</Button>
);
case 'paid':
return (
<Button>free</Button>
);
default:
return null;
}
};
const Waiter = () => (
<Paper className={styles.component}>
<Table>
<TableHead>
<TableRow>
<TableCell>Table</TableCell>
<TableCell>Status</TableCell>
<TableCell>Order</TableCell>
<TableCell>Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{demoContent.map(row => (
<TableRow key={row.id}>
<TableCell component="th" scope="row">
{row.id}
</TableCell>
<TableCell>
{row.status}
</TableCell>
<TableCell>
{row.order && (
<Button to={`${process.env.PUBLIC_URL}/waiter/order/${row.order}`}>
{row.order}
</Button>
)}
</TableCell>
<TableCell>
{renderActions(row.status)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
);
export default Waiter;
Zadanie: Stworzenie widoków
Teraz kiedy możemy już w pełni stosować Material-UI i jeden widok mamy już przygotowany, czas na zakodowanie pozostałych widoków.
Podobnie, jak w widoku kelnera, użyj przykładowych danych wprowadzonych bezpośrednio w kodzie komponentu.
22.2. Wdrożenie Reduksa
Mamy już działający routing oraz zakodowane widoki z przykładowymi danymi. Teraz zajmiemy się implementacją Reduksa, aby nasza aplikacja mogła ożyć!
Redux
To już znamy, ale powtórzmy sobie całą procedurę! W ramach przykładu pobierzemy listę produktów i zachowamy ją w stanie aplikacji. Następnie wyświetlimy nazwy produktów w widoku Waiter, czyli pod adresem /waiter.
Jeśli w Twoim projekcie ten komponent ma inną nazwę, nie musisz jej zmieniać – pamiętaj tylko o zmianie nazwy i zawartości jego kontenera, który za chwilę będziemy tworzyć.
1. Plik store.js
Ten plik umieszczamy w src/redux. Użyjemy schematu z projektu z aplikacji to-do, ponieważ nie będzie nam tutaj potrzebny globalny reducer.
W poniższym kodzie od razu uwzględniliśmy implementację Thunka, o którym powiemy sobie za chwilę. W związku z tym nie zdziw się, że na razie poniższy kod nie będzie działał poprawnie, dopóki nie zainstalujesz pakietu redux-thunk (do czego za chwilę dojdziemy).
import {combineReducers, createStore, applyMiddleware} from 'redux';
import thunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
import tablesReducer from './tablesRedux';
// define initial state and shallow-merge initial data
const initialState = {
tables: {
data: {},
loading: {
active: false,
error: false,
},
},
};
// define reducers
const reducers = {
tables: tablesReducer,
};
// add blank reducers for initial state properties without reducers
Object.keys(initialState).forEach(item => {
if (typeof reducers[item] == 'undefined') {
reducers[item] = (statePart = null) => statePart;
}
});
const combinedReducers = combineReducers(reducers);
// create store
const store = createStore(
combinedReducers,
initialState,
composeWithDevTools(
applyMiddleware(thunk)
)
);
export default store;
Zawarliśmy w nim od razu początkowy stan aplikacji, w którym w obiekcie tables mamy dwa obiekty:
loadingzawiera informacje o wczytywaniu danych –activemówi nam, czy trwa wczytywanie, aerrorbędzie zawierał ew. komunikat o błędzie,databędzie zawierać tablicę stolików, które pobierzemy z API.
2. Plik tablesRedux.js
Drugim bardzo ważnym plikiem będzie reduksowa konfiguracja dla produktów, którą również umieścimy w src/redux. Jego strukturę dobrze już znamy – od razu zawarliśmy w niej dwa selektory, które za chwilę wykorzystamy.
Poza selektorami, mamy też trzy akcje, ich kreatory, oraz obsługujący je reducer. Produkty będziemy pobierać z API, więc chcemy, żeby nasza aplikacja wiedziała (czyli miała informacje zapisane w stanie) o tym, że rozpoczęło się pobieranie produktów. Dzięki temu będziemy mogli np. wyświetlić ikonę wczytywania.
Połączenie z API może się udać albo nie – np. jeśli użytkownik straci połączenie z internetem. W przypadku błędu również chcemy poinformować o tym aplikację, a na stronie wyświetlić komunikat o błędzie.
import Axios from 'axios';
/* selectors */
export const getAll = ({tables}) => tables.data;
export const getLoadingState = ({tables}) => tables.loading;
/* action name creator */
const reducerName = 'tables';
const createActionName = name => `app/${reducerName}/${name}`;
/* action types */
const FETCH_START = createActionName('FETCH_START');
const FETCH_SUCCESS = createActionName('FETCH_SUCCESS');
const FETCH_ERROR = createActionName('FETCH_ERROR');
/* action creators */
export const fetchStarted = payload => ({ payload, type: FETCH_START });
export const fetchSuccess = payload => ({ payload, type: FETCH_SUCCESS });
export const fetchError = payload => ({ payload, type: FETCH_ERROR });
/* reducer */
export default function reducer(statePart = [], action = {}) {
switch (action.type) {
case FETCH_START: {
return {
...statePart,
loading: {
active: true,
error: false,
},
};
}
case FETCH_SUCCESS: {
return {
...statePart,
loading: {
active: false,
error: false,
},
data: action.payload,
};
}
case FETCH_ERROR: {
return {
...statePart,
loading: {
active: false,
error: action.payload,
},
};
}
default:
return statePart;
}
}
3. Zmiany w App.js
Kiedy mamy już przygotowany magazyn, możemy go wykorzystać za pomocą komponentu Provider. Zaimportuj go z pakietu react-redux, będziemy też potrzebowali zaimportować nasz store. Następnie zawrzyj cały kod JSX w komponencie <Provider store={store}>.
Jeśli wszystko poszło dobrze, Redux powinien już działać. Możesz to sprawdzić za pomocą narzędzi developerskich (zakładka Redux).
Reduks i API
Zastanówmy się teraz, jak powinno działać pierwsze zapytanie do API, które pobierze listę produktów. Musielibyśmy najpierw zrobić dispatch akcji FETCH_START, następnie wysłać zapytanie do serwera, a po jego odpowiedzi zdispatchować akcję FETCH_SUCCESS. W przypadku błędu, zamiast niej użylibyśmy FETCH_ERROR.
Pojawia się jednak pytanie: gdzie mamy zapisać tę logikę? Do tej pory pracowaliśmy w założeniu, że komponent nie wie niczego o reduksie, więc na pewno nie w nim. Może w kontenerze? Też nie, on służy do przypisania stanu i dispatcherów do propsów. W takim razie zostaje nam plik tablesRedux.js – ale w jaki sposób mielibyśmy tę funkcję przekazać do propsa komponentu?
W środowisku developerów Reacta są na to dwie najpopularniejsze odpowiedzi – React Thunk i React Saga. Nie ma konsensusu co do wyższości jednego rozwiązania nad drugim, ale z całą pewnością Thunk będzie dla Ciebie łatwiejszy do zrozumienia na tym etapie nauki – dlatego to jego zastosujemy.
Czym jest React Thunk?
Jest to middleware. Ogólnie rzecz biorąc, middleware to jakiś kod, który stosuje się w środku jakiegoś procesu. Tak właśnie jest w przypadku Thunka – zostanie on "wstrzelony" w proces przetwarzania reduksowych akcji. Kiedy zdispatchujemy akcję, zostanie ona przechwycona przez Thunka. Dzięki temu możemy zastosować nowy rodzaj akcji!
Do tej pory nasze akcje były tworzone przez kreatory akcji, które zwracały obiekt z dwoma właściwościami – payload i type. Od teraz będziemy mogli również dispatchować funkcje! Dzięki temu cała logika procedury pobierania produktów z API znajdzie się w tzw. thunku, czyli funkcji przystosowanej do przechwycenia przez React Thunk, która ma za zadanie dispatchować inne akcje. Nie musi tego jednak robić natychmiast – może to zrobić np. po pobraniu informacji z serwera.
Dzięki temu rozwiązaniu ani komponent, ani jego kontener, nie muszą wiedzieć o istnieniu API. Jedyne co komponent będzie musiał wiedzieć, to że ma uruchomić funkcję otrzymaną w propsie fetchTables, który za chwilę mu przypiszemy. Wystarczy tę funkcję uruchomić, nie musi jej przekazywać żadnych argumentów, ani odczytywać tego, co jest przez nią zwracane.
Kontener powiąże tego propsa z dispatcherem wykorzystującym kreator akcji, który umieścimy w pliku tablesRedux.js. W ten sposób tylko ten plik będzie wiedział zarówno o strukturze reduksowego stanu aplikacji, jak i o istnieniu API.
Instalacja Thunka i Axiosa
Oprócz Thunka będziemy też używać pakietu Axios, którego będziemy używać zamiast fetch (wbudowanego w przeglądarkę). Ułatwi nam to wykonywanie połączeń AJAX-owych, a w szczególności wychwytywanie błędów. Jego składnia jest bardzo podobna do fetch, a nawet nieco prostsza, co za chwilę zobaczysz w praktyce.
Zainstaluj te pakiety za pomocą komendy:
yarn add redux-thunk axios
Implementacja Thunka
W podanym powyżej kodzie pliku store.js uwzględniliśmy już implementację Thunka. Po pierwsze, polega ona na dodaniu odpowiednich importów:
import thunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
Drugi z tych importów dotyczy narzędzi developerskich dla Reduksa, ponieważ musimy zmienić sposób ich inicjowania w projekcie. Przejdź teraz na koniec pliku i znajdź ten fragment:
composeWithDevTools(
applyMiddleware(thunk)
)
W tym miejscu bez implementacji Thunka mieliśmy taką linię kodu:
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
Dzięki tej zmianie będą działać jednocześnie narzędzia developerskie dla Reduksa, jak i Thunk.
Nasz pierwszy thunk
Zaczynamy od dodania thunka (a właściwie jego kreatora) do pliku tablesRedux.js. Tuż nad komentarzem /* reducer */ dodaj następujący kod:
/* thunk creators */
export const fetchFromAPI = () => {
return (dispatch, getState) => {
dispatch(fetchStarted());
Axios
.get(`${api.url}/api/${api.tables}`)
.then(res => {
dispatch(fetchSuccess(res.data));
})
.catch(err => {
dispatch(fetchError(err.message || true));
});
};
};
Nasz kreator thunka, czyli fetchFromAPI, jest funkcją, która nie przyjmuje żadnych argumentów. Zwraca ona thunka, czyli funkcję. Thunk przyjmuje dwa argumenty – dispatch i getState. Pierwszy z nich, podobnie jak w mapDispatchToProps w kontenerze komponentu, jest funkcją służącą do dispatchowania akcji. Drugi, getState, jest funkcją pozwalającą na pobranie stanu aplikacji.
Dzięki tym dwóm funkcjom będziemy mogli zrealizować algorytm, który zapisaliśmy wcześniej. Dispatchujemy akcję z kreatora fetchStarted, czyli typu FETCH_START, a następnie uruchamiamy połączenie z API z pomocą Axiosa. Jego metoda .get służy do wysyłania zapytań metodą GET – analogicznie, do wysłania nowego zamówienia do API używalibyśmy metody .post. Zwróć uwagę, że adres API pobieramy z obiektu api, który jeszcze nie został zaimportowany – zajmiemy się tym za chwilę.
Następnie używamy metody .then do zareagowania na odpowiedź serwera – w tym przypadku dispatchujemy akcję FETCH_SUCCESS, uruchamiając jej kreator fetchSuccess, któremu jako argument przekazujemy dane otrzymane z serwera, czyli res.data.
Jeżeli wystąpił błąd połączenia, zamiast funkcji podanej w metodzie .then wykona się funkcja z metody .catch – w tym wypadku dispatcher akcji FETCH_ERROR, którego kreator fetchError otrzyma komunikat o błędzie (a w przypadku braku komunikatu, otrzyma true).
Ustawienia API i importy
Być może udało Ci się zauważyć, że w adresie podanym w metodzie .get używamy obiektu api – zapiszemy go sobie w pliku src/settings.js, aby tam przechowywać ustawienia naszej aplikacji. Dzięki temu informacje takie jak adres API będą zapisane tylko w jednym miejscu i łatwo będzie je zmienić w razie potrzeby.
export const api = {
url: '//' + window.location.hostname + (window.location.hostname=='localhost' ? ':3131' : ''),
tables: 'tables',
products: 'products',
order: 'order',
booking: 'booking',
event: 'event',
dateStartParamKey: 'date_gte',
dateEndParamKey: 'date_lte',
notRepeatParam: 'repeat=false',
repeatParam: 'repeat_ne=false',
};
Wracając do pliku tablesRedux.js, musimy w nim zaimportować Axiosa oraz obiekt api. Dodaj na początku pliku:
import Axios from "axios";
import { api } from "../settings";
Uzupełnienie bazy danych
W naszej bazie danych, czyli w pliku public/db/app.json, nie ma jeszcze żadnych informacji o stolikach. Wystarczy jednak, że do obiektu znajdującego się w tym pliku dodasz nową właściwość o kluczu tables. Jej wartością może być tablica, która do tej pory znajdowała się w komponencie Waiter, zapisana w stałej demoContent.
Kontener komponentu
Teraz kiedy mamy już kreator thunka, możemy stworzyć kontener komponentu Waiter w pliku WaiterContainer.js. Ponownie użyjemy dobrze nam znanej struktury – tym razem pliku kontenera. Wykorzystamy w nim selektory oraz kreator thunka z pliku tablesRedux.js.
import { connect } from 'react-redux'
import Waiter from './Waiter';
import { getAll, fetchFromAPI, getLoadingState } from '../../../redux/tablesRedux';
const mapStateToProps = (state) => ({
tables: getAll(state),
loading: getLoadingState(state),
})
const mapDispatchToProps = (dispatch) => ({
fetchTables: () => dispatch(fetchFromAPI()),
})
export default connect(mapStateToProps, mapDispatchToProps)(Waiter);
Jak widzisz, dispatcher wygląda dokładnie tak samo, jak przy używanych przez nas do tej pory kreatorach akcji. Podobnie, w samym komponencie też będziemy w ten sam sposób wykorzystywać propsy.
Zanim jednak przejdziemy do tego kroku, musimy koniecznie pamiętać o zmianie ścieżki importu w pliku App.js – tam importujemy komponent Waiter z pliku Waiter.js, a teraz ma importować z WaiterContainer.js.
To bardzo ważny krok przy tworzeniu kontenera, bo inaczej za chwilę będziemy sobie rwać włosy z głowy, szukając błędu w kodzie komponentu. ;)
Wyświetlenie danych w komponencie
Wreszcie, możemy zająć się komponentem. Musimy go przerobić na komponent klasowy, ponieważ będziemy potrzebowali użyć jednej z metod jego cyklu życia, czyli componentDidMount. Ta metoda jest uruchamiana przez Reacta w momencie, gdy ten komponent zostanie stworzony i wyświetlony na stronie – czyli zamontowany (mounted).
Ma to tę olbrzymią zaletę, że jeśli otworzymy nasz panel np. na stronie głównej, to nie będą pobierane produkty z API. Pozwoli nam to na szybkie działanie strony, ponieważ dane będą pobierane tylko w razie potrzeby.
Zobaczmy więc, jak powinien wyglądać ten komponent:
import React from 'react';
import PropTypes from 'prop-types';
import styles from './Waiter.module.scss';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Paper from '@material-ui/core/Paper';
import Button from '@material-ui/core/Button';
class Waiter extends React.Component {
static propTypes = {
fetchTables: PropTypes.func,
loading: PropTypes.shape({
active: PropTypes.bool,
error: PropTypes.oneOfType([PropTypes.bool,PropTypes.string]),
}),
}
componentDidMount(){
const { fetchTables } = this.props;
fetchTables();
}
renderActions(status){
switch (status) {
case 'free':
return (
<>
<Button>thinking</Button>
<Button>new order</Button>
</>
);
case 'thinking':
return (
<Button>new order</Button>
);
case 'ordered':
return (
<Button>prepared</Button>
);
case 'prepared':
return (
<Button>delivered</Button>
);
case 'delivered':
return (
<Button>paid</Button>
);
case 'paid':
return (
<Button>free</Button>
);
default:
return null;
}
};
render() {
const { loading: { active, error }, tables } = this.props;
if(active || !tables.length){
return (
<Paper className={styles.component}>
<p>Loading...</p>
</Paper>
);
} else if(error) {
return (
<Paper className={styles.component}>
<p>Error! Details:</p>
<pre>{error}</pre>
</Paper>
);
} else {
return (
<Paper className={styles.component}>
<Table>
<TableHead>
<TableRow>
<TableCell>Table</TableCell>
<TableCell>Status</TableCell>
<TableCell>Order</TableCell>
<TableCell>Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tables.map(row => (
<TableRow key={row.id}>
<TableCell component="th" scope="row">
{row.id}
</TableCell>
<TableCell>
{row.status}
</TableCell>
<TableCell>
{row.order && (
<Button to={`${process.env.PUBLIC_URL}/waiter/order/${row.order}`}>
{row.order}
</Button>
)}
</TableCell>
<TableCell>
{this.renderActions(row.status)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
);
}
}
}
export default Waiter;
Mamy w tym kodzie parę nowości związanych ze składnią:
- w
propTypesużyliśmyPropTypes.shape, który pozwala nam zdefiniować typy właściwości obiektuloading, - w dekonstrukcji propsów w metodzie
renderod razu dekonstruujemy również obiektloading, - stosujemy wyrażenie
if...else if...else, w którym każdy blok kodu zawierareturn.
W efekcie mamy trzy warianty zawartości tego komponentu, które zależą od statusu połączenia z API.
Podsumowanie
Podsumujmy krótko zastosowanie React Thunk do pobrania produktów z API. Tym razem przejdziemy tę ścieżkę nie w kolejności, w jakiej pisaliśmy kod, ale w kolejności jego wykonywania przez aplikację.
Kiedy użytkownik wchodzi na stronę widoku kelnera, komponent Waiter zostaje zamontowany (ale dopiero za chwilę się wyrenderuje). W tym momencie uruchamia funkcję, którą otrzymał w propsie fetchTables.
A skąd wzięła się ta funkcja? Została przekazana do propsa przez kontener komponentu – to tam przypisujemy do tego propsa następującą funkcję:
() => dispatch(fetchFromAPI())
To właśnie tę funkcję wykonaliśmy po zamontowaniu komponentu! Wywoła ona funkcję dispatch, a jako jej argument poda wynik funkcji fetchFromAPI. Ta ostatnia jest kreatorem thunka i znajduje się w tablesRedux.js – zwraca ona thunka, czyli funkcję przyjmująca dwa argumenty, nazwane przez nas dispatch i getState. Za pomocą drugiej z nich sprawdzamy, czy w stanie aplikacji są już jakieś produkty.
Wtedy zostanie zdispatchowana akcja, która w stanie aplikacji zapisze, że trwa proces wczytywania produktów z API. Następnie zostanie zapoczątkowane połączenie z API.
Zatrzymajmy się na chwilę – nasza aplikacja będzie teraz czekać na odpowiedź serwera. W międzyczasie możemy wrócić do komponentu Waiter, który właśnie się renderuje. W stanie aplikacji znalazł informację o trwającym zapytaniu do API, więc wyświetla napis "Loading".
Wracając do naszego thunka, serwer zwrócił odpowiedź. Połączenie się powiodło i dostaliśmy dane stolików. Nasz thunk dispatchuje akcję, w której będą przekazane dane otrzymane z API.
Magazyn, po otrzymaniu tej akcji, jak zwykle wysyła akcję najpierw do middleware'a – Thunk patrzy na tę akcję, widzi że ona nie jest funkcją, więc nie zajmuje się nią. W takim razie akcja trafia do reducera, który w reakcji na tę akcję dodaje do stanu aplikacji dane stolików.
Po zmianie stanu aplikacji magazyn wymusza ponowne wyrenderowanie komponentu Waiter, który tym razem wyświetli listę produktów.
I żyli długo i szczęśliwie...
Zadanie: Implementacja zmiany statusu stolika
To była długa droga, ale przy każdym kolejnym widoku będzie łatwiej. Na razie jedynie odczytujemy dane z API.
Twoim zadaniem jest zaimplementowanie funkcjonalności, która pozwoli na zmianę statusów poszczególnych stolików, po kliknięciu w odpowiedni guzik.
W tym celu musisz stworzyć:
- nowy typ i kreator akcji, odpowiedzialne za aktualizację statusu pojedynczego stolika,
- nowy thunk, który zapisze odpowiednie zmiany w API, a po otrzymaniu odpowiedzi zaktualizuje stan aplikacji,
- w kontenerze komponentu
Waiter, nowe powiązanie stanu z propsem, wykorzystujące stworzony thunk, - w komponencie
Waiter, propsa odpowiedniego guzika, który wywoła funkcję wspomnianą w poprzednim punkcie, przekazując jejidstolika oraz nowy status.
Powodzenia!
22.3. Dalszy rozwój aplikacji
Dalszy rozwój aplikacji nie powinien już stanowić problemu – analogicznie do widoku Waiter, należy podłączyć pozostałe widoki do stanu aplikacji oraz zaimplementować ich funkcjonalności (zwykle: zmiany stanu aplikacji).
Dokończenie tego projektu nie jest jednak obowiązkowe – możesz realizować go stopniowo lub wrócić do niego po kursie. Zależy nam jednak, aby nie narzucać Ci zabójczego tempa realizacji dalszych kroków w tym projekcie.
Powodzenia!